/*******************************************************************************
 * Copyright (c) 2012-2015 Codenvy, S.A.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v10.html
 *
 * Contributors:
 *   Codenvy, S.A. - initial API and implementation
 *******************************************************************************/
package org.eclipse.che.jdt.core.launching;

import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IPath;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Path;
import org.eclipse.core.runtime.Status;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

/**
 * @author Evgen Vidolob
 */
public class Launching {
    private static final Logger LOG       = LoggerFactory.getLogger(Launching.class);
    /**
     * The id of the JDT launching plug-in (value <code>"org.eclipse.jdt.launching"</code>).
     */
    public static final  String ID_PLUGIN = "org.eclipse.jdt.launching"; //$NON-NLS-1$
    public static boolean DEBUG_JRE_CONTAINER = false;
    /**
     * Mapping of top-level VM installation directories to library info for that
     * VM.
     */
    private static Map<String, LibraryInfo> fgLibraryInfoMap = null;
    private static Launching fgLaunching;

    /**
     * Mutex for checking the time stamp of an install location
     */
    private static Object installLock = new Object();

    /**
     * List of install locations that have been detected to have changed
     */
    private static HashSet<String> fgHasChanged = new HashSet<>();

    /**
     * Mapping of the last time the directory of a given SDK was modified.
     * <br><br>
     * Mapping: <code>Map&lt;String,Long&gt;</code>
     */
    private static Map<String, Long> fgInstallTimeMap = null;

    /**
     * Status code indicating an unexpected error.
     *
     * @since 3.4
     */
    public static final int ERROR = 125;

    /**
     * Returns the library info that corresponds to the specified JRE install
     * path, or <code>null</code> if none.
     *
     * @param javaInstallPath the absolute path to the java executable
     * @return the library info that corresponds to the specified JRE install
     * path, or <code>null</code> if none
     */
    public static LibraryInfo getLibraryInfo(String javaInstallPath) {
        if (fgLibraryInfoMap == null) {
            restoreLibraryInfo();
        }
        return fgLibraryInfoMap.get(javaInstallPath);
    }

    /**
     * Restores library information for VMs
     */
    private static void restoreLibraryInfo() {
        fgLibraryInfoMap = new HashMap<String, LibraryInfo>(10);
        IPath libPath = getDefault().getStateLocation();
        libPath = libPath.append("libraryInfos.xml"); //$NON-NLS-1$
        File file = libPath.toFile();
        if (file.exists()) {
            try {
                InputStream stream = new BufferedInputStream(new FileInputStream(file));
                DocumentBuilder parser = DocumentBuilderFactory.newInstance().newDocumentBuilder();
                parser.setErrorHandler(new DefaultHandler());
                Element root = parser.parse(new InputSource(stream)).getDocumentElement();
                if (!root.getNodeName().equals("libraryInfos")) { //$NON-NLS-1$
                    return;
                }

                NodeList list = root.getChildNodes();
                int length = list.getLength();
                for (int i = 0; i < length; ++i) {
                    Node node = list.item(i);
                    short type = node.getNodeType();
                    if (type == Node.ELEMENT_NODE) {
                        Element element = (Element) node;
                        String nodeName = element.getNodeName();
                        if (nodeName.equalsIgnoreCase("libraryInfo")) { //$NON-NLS-1$
                            String version = element.getAttribute("version"); //$NON-NLS-1$
                            String location = element.getAttribute("home"); //$NON-NLS-1$
                            String[] bootpath = getPathsFromXML(element, "bootpath"); //$NON-NLS-1$
                            String[] extDirs = getPathsFromXML(element, "extensionDirs"); //$NON-NLS-1$
                            String[] endDirs = getPathsFromXML(element, "endorsedDirs"); //$NON-NLS-1$
                            if (location != null) {
                                LibraryInfo info = new LibraryInfo(version, bootpath, extDirs, endDirs);
                                fgLibraryInfoMap.put(location, info);
                            }
                        }
                    }
                }
            } catch (IOException | SAXException | ParserConfigurationException e) {
                log(e);
            }
        }
    }

    /**
     * Returns the location in the local file system of the
     * plug-in state area for this plug-in.
     * If the plug-in state area did not exist prior to this call,
     * it is created.
     * @throws IllegalStateException, when the system is running with no data area (-data @none),
     * or when a data area has not been set yet.
     * @return a local file system path
     */
    public final IPath getStateLocation() throws IllegalStateException {

        Path path = new Path("/tmp/codenvy/");
        File file = path.toFile();
        if (!file.exists()){
            file.mkdirs();
        }
        return path;
    }

    /**
     * Returns the singleton instance of <code>LaunchingPlugin</code>
     * @return the singleton instance of <code>LaunchingPlugin</code>
     */
    public static Launching getDefault() {
        if(fgLaunching == null) {
            fgLaunching = new Launching();
        }
        return fgLaunching;
    }

    /**
     * Logs the specified status
     * @param status the status to log
     */
    public static void log(IStatus status) {
//        getDefault().getLog().log(status);
        LOG.error(status.getMessage(), status.getException());
    }

    /**
     * Logs the specified message, by creating a new <code>Status</code>
     * @param message the message to log as an error status
     */
    public static void log(String message) {
        log(new Status(IStatus.ERROR, getUniqueIdentifier(), IStatus.ERROR, message, null));
    }

    /**
     * Logs the specified exception by creating a new <code>Status</code>
     * @param e the {@link Throwable} to log as an error
     */
    public static void log(Throwable e) {
        log(new Status(IStatus.ERROR, getUniqueIdentifier(), IStatus.ERROR, e.getMessage(), e));
    }

    /**
     * Convenience method which returns the unique identifier of this plug-in.
     *
     * @return the id of the {@link Launching}
     */
    public static String getUniqueIdentifier() {
        return ID_PLUGIN;
    }

    /**
     * Returns paths stored in XML
     * @param lib the library path in {@link Element} form
     * @param pathType the type of the path
     * @return paths stored in XML
     */
    private static String[] getPathsFromXML(Element lib, String pathType) {
        List<String> paths = new ArrayList<String>();
        NodeList list = lib.getChildNodes();
        int length = list.getLength();
        for (int i = 0; i < length; ++i) {
            Node node = list.item(i);
            short type = node.getNodeType();
            if (type == Node.ELEMENT_NODE) {
                Element element = (Element) node;
                String nodeName = element.getNodeName();
                if (nodeName.equalsIgnoreCase(pathType)) {
                    NodeList entries = element.getChildNodes();
                    int numEntries = entries.getLength();
                    for (int j = 0; j < numEntries; j++) {
                        Node n = entries.item(j);
                        short t = n.getNodeType();
                        if (t == Node.ELEMENT_NODE) {
                            Element entryElement = (Element)n;
                            String name = entryElement.getNodeName();
                            if (name.equals("entry")) { //$NON-NLS-1$
                                String path = entryElement.getAttribute("path"); //$NON-NLS-1$
                                if (path != null && path.length() > 0) {
                                    paths.add(path);
                                }
                            }
                        }
                    }
                }
            }
        }
        return paths.toArray(new String[paths.size()]);
    }

    /**
     * Checks to see if the time stamp of the file describe by the given location string
     * has been modified since the last recorded time stamp. If there is no last recorded
     * time stamp we assume it has changed. See https://bugs.eclipse.org/bugs/show_bug.cgi?id=266651 for more information
     *
     * @param location the location of the SDK we want to check the time stamp for
     * @return <code>true</code> if the time stamp has changed compared to the cached one or if there is
     * no recorded time stamp, <code>false</code> otherwise.
     */
    public static boolean timeStampChanged(String location) {
        synchronized (installLock) {
            if(fgHasChanged.contains(location)) {
                return true;
            }
            File file = new File(location);
            if(file.exists()) {
                if(fgInstallTimeMap == null) {
                    readInstallInfo();
                }
                Long stamp = fgInstallTimeMap.get(location);
                long fstamp = file.lastModified();
                if(stamp != null) {
                    if(stamp.longValue() == fstamp) {
                        return false;
                    }
                }
                //if there is no recorded stamp we have to assume it is new
                stamp = new Long(fstamp);
                fgInstallTimeMap.put(location, stamp);
                writeInstallInfo();
                fgHasChanged.add(location);
                return true;
            }
        }
        return false;
    }

    /**
     * Reads the file of saved time stamps and populates the {@link #fgInstallTimeMap}.
     * See https://bugs.eclipse.org/bugs/show_bug.cgi?id=266651 for more information
     *
     * @since 3.7
     */
    private static void readInstallInfo() {
        fgInstallTimeMap = new HashMap<String, Long>();
        IPath libPath = getDefault().getStateLocation();
        libPath = libPath.append(".install.xml"); //$NON-NLS-1$
        File file = libPath.toFile();
        if (file.exists()) {
            try {
                InputStream stream = new BufferedInputStream(new FileInputStream(file));
                DocumentBuilder parser = DocumentBuilderFactory.newInstance().newDocumentBuilder();
                parser.setErrorHandler(new DefaultHandler());
                Element root = parser.parse(new InputSource(stream)).getDocumentElement();
                if(root.getNodeName().equalsIgnoreCase("dirs")) { //$NON-NLS-1$
                    NodeList nodes = root.getChildNodes();
                    Node node = null;
                    Element element = null;
                    for (int i = 0; i < nodes.getLength(); i++) {
                        node = nodes.item(i);
                        if(node.getNodeType() == Node.ELEMENT_NODE) {
                            element = (Element) node;
                            if(element.getNodeName().equalsIgnoreCase("entry")) { //$NON-NLS-1$
                                String loc = element.getAttribute("loc"); //$NON-NLS-1$
                                String stamp = element.getAttribute("stamp"); //$NON-NLS-1$
                                try {
                                    Long l = new Long(stamp);
                                    fgInstallTimeMap.put(loc, l);
                                }
                                catch(NumberFormatException nfe) {
                                    //do nothing
                                }
                            }
                        }
                    }
                }
            } catch (IOException e) {
                log(e);
            } catch (ParserConfigurationException e) {
                log(e);
            } catch (SAXException e) {
                log(e);
            }
        }
    }

    /**
     * Sets the library info that corresponds to the specified JRE install
     * path.
     *
     * @param javaInstallPath home location for a JRE
     * @param info the library information, or <code>null</code> to remove
     */
    public static void setLibraryInfo(String javaInstallPath, LibraryInfo info) {
        if (fgLibraryInfoMap == null) {
            restoreLibraryInfo();
        }
        if (info == null) {
            fgLibraryInfoMap.remove(javaInstallPath);
            if(fgInstallTimeMap != null) {
                fgInstallTimeMap.remove(javaInstallPath);
                writeInstallInfo();
            }

        } else {
            fgLibraryInfoMap.put(javaInstallPath, info);
        }
        //once the library info has been set we can forget it has changed
        fgHasChanged.remove(javaInstallPath);
        saveLibraryInfo();
    }

    /**
     * Saves the library info in a local workspace state location
     */
    private static void saveLibraryInfo() {
        OutputStream stream= null;
        try {
            String xml = getLibraryInfoAsXML();
            IPath libPath = getDefault().getStateLocation();
            libPath = libPath.append("libraryInfos.xml"); //$NON-NLS-1$
            File file = libPath.toFile();
            if (!file.exists()) {
                file.createNewFile();
            }
            stream = new BufferedOutputStream(new FileOutputStream(file));
            stream.write(xml.getBytes("UTF8")); //$NON-NLS-1$
        } catch (IOException e) {
            log(e);
        }  catch (CoreException e) {
            log(e);
        } finally {
            if (stream != null) {
                try {
                    stream.close();
                } catch (IOException e1) {
                }
            }
        }
    }
    /**
     * Return the VM definitions contained in this object as a String of XML.  The String
     * is suitable for storing in the workbench preferences.
     * <p>
     * The resulting XML is compatible with the static method <code>parseXMLIntoContainer</code>.
     * </p>
     * @return String the results of flattening this object into XML
     * @throws CoreException if this method fails. Reasons include:<ul>
     * <li>serialization of the XML document failed</li>
     * </ul>
     */
    private static String getLibraryInfoAsXML() throws CoreException {

        Document doc = newDocument();
        Element config = doc.createElement("libraryInfos");    //$NON-NLS-1$
        doc.appendChild(config);

        // Create a node for each info in the table
        Iterator<String> locations = fgLibraryInfoMap.keySet().iterator();
        while (locations.hasNext()) {
            String home = locations.next();
            LibraryInfo info = fgLibraryInfoMap.get(home);
            Element locationElemnet = infoAsElement(doc, info);
            locationElemnet.setAttribute("home", home); //$NON-NLS-1$
            config.appendChild(locationElemnet);
        }

        // Serialize the Document and return the resulting String
        return serializeDocument(doc);
    }

    /**
     * Creates an XML element for the given info.
     *
     * @param doc the backing {@link Document}
     * @param info the {@link LibraryInfo} to add to the {@link Document}
     * @return Element
     */
    private static Element infoAsElement(Document doc, LibraryInfo info) {
        Element libraryElement = doc.createElement("libraryInfo"); //$NON-NLS-1$
        libraryElement.setAttribute("version", info.getVersion()); //$NON-NLS-1$
        appendPathElements(doc, "bootpath", libraryElement, info.getBootpath()); //$NON-NLS-1$
        appendPathElements(doc, "extensionDirs", libraryElement, info.getExtensionDirs()); //$NON-NLS-1$
        appendPathElements(doc, "endorsedDirs", libraryElement, info.getEndorsedDirs()); //$NON-NLS-1$
        return libraryElement;
    }

    /**
     * Appends path elements to the given library element, rooted by an
     * element of the given type.
     *
     * @param doc the backing {@link Document}
     * @param elementType the kind of {@link Element} to create
     * @param libraryElement the {@link Element} describing a given {@link LibraryInfo} object
     * @param paths the paths to add
     */
    private static void appendPathElements(Document doc, String elementType, Element libraryElement, String[] paths) {
        if (paths.length > 0) {
            Element child = doc.createElement(elementType);
            libraryElement.appendChild(child);
            for (int i = 0; i < paths.length; i++) {
                String path = paths[i];
                Element entry = doc.createElement("entry"); //$NON-NLS-1$
                child.appendChild(entry);
                entry.setAttribute("path", path); //$NON-NLS-1$
            }
        }
    }


    /**
     * Returns a Document that can be used to build a DOM tree
     * @return the Document
     * @throws ParserConfigurationException if an exception occurs creating the document builder
     */
    public static Document getDocument() throws ParserConfigurationException {
        DocumentBuilderFactory dfactory= DocumentBuilderFactory.newInstance();
        DocumentBuilder docBuilder= dfactory.newDocumentBuilder();
        Document doc= docBuilder.newDocument();
        return doc;
    }

    /**
     * Creates and returns a new XML document.
     *
     * @return a new XML document
     * @throws CoreException if unable to create a new document
     */
    public static Document newDocument()throws CoreException {
        try {
            return getDocument();
        } catch (ParserConfigurationException e) {
            abort("Unable to create new XML document.", e);  //$NON-NLS-1$
        }
        return null;
    }

    /**
     * Serializes a XML document into a string - encoded in UTF8 format,
     * with platform line separators.
     *
     * @param doc document to serialize
     * @return the document as a string
     * @throws TransformerException if an unrecoverable error occurs during the serialization
     * @throws IOException if the encoding attempted to be used is not supported
     */
    private static String serializeDocumentInt(Document doc) throws TransformerException, IOException {
        ByteArrayOutputStream s = new ByteArrayOutputStream();
        TransformerFactory factory = TransformerFactory.newInstance();
        Transformer transformer = factory.newTransformer();
        transformer.setOutputProperty(OutputKeys.METHOD, "xml"); //$NON-NLS-1$
        transformer.setOutputProperty(OutputKeys.INDENT, "yes"); //$NON-NLS-1$
        DOMSource source = new DOMSource(doc);
        StreamResult outputTarget = new StreamResult(s);
        transformer.transform(source, outputTarget);
        return s.toString("UTF8"); //$NON-NLS-1$
    }

    /**
     * Serializes the given XML document into a string.
     *
     * @param document XML document to serialize
     * @return a string representing the given document
     * @throws CoreException if unable to serialize the document
     */
    public static String serializeDocument(Document document) throws CoreException {
        try {
            return serializeDocumentInt(document);
        } catch (TransformerException e) {
            abort("Unable to serialize XML document.", e);  //$NON-NLS-1$
        } catch (IOException e) {
            abort("Unable to serialize XML document.",e);  //$NON-NLS-1$
        }
        return null;
    }

    /**
     * Throws an exception with the given message and underlying exception.
     *
     * @param message error message
     * @param exception underlying exception, or <code>null</code>
     * @throws CoreException if a problem is encountered
     */
    private static void abort(String message, Throwable exception) throws CoreException {
        IStatus status = new Status(IStatus.ERROR, getUniqueIdentifier(), ERROR, message, exception);
        throw new CoreException(status);
    }

    /**
     * Writes out the mappings of SDK install time stamps to disk. See
     * https://bugs.eclipse.org/bugs/show_bug.cgi?id=266651 for more information.
     */
    private static void writeInstallInfo() {
        if(fgInstallTimeMap != null) {
            OutputStream stream= null;
            try {
                Document doc = newDocument();
                Element root = doc.createElement("dirs");    //$NON-NLS-1$
                doc.appendChild(root);
                Map.Entry<String, Long> entry = null;
                Element e = null;
                String key = null;
                for(Iterator<Map.Entry<String, Long>> i = fgInstallTimeMap.entrySet().iterator(); i.hasNext();) {
                    entry = i.next();
                    key = entry.getKey();
                    if(fgLibraryInfoMap == null || fgLibraryInfoMap.containsKey(key)) {
                        //only persist the info if the library map also has info OR is null - prevent persisting deleted JRE information
                        e = doc.createElement("entry"); //$NON-NLS-1$
                        root.appendChild(e);
                        e.setAttribute("loc", key); //$NON-NLS-1$
                        e.setAttribute("stamp", entry.getValue().toString()); //$NON-NLS-1$
                    }
                }
                String xml = serializeDocument(doc);
                IPath libPath = getDefault().getStateLocation();
                libPath = libPath.append(".install.xml"); //$NON-NLS-1$
                File file = libPath.toFile();
                if (!file.exists()) {
                    file.createNewFile();
                }
                stream = new BufferedOutputStream(new FileOutputStream(file));
                stream.write(xml.getBytes("UTF8")); //$NON-NLS-1$
            } catch (IOException e) {
                log(e);
            }  catch (CoreException e) {
                log(e);
            } finally {
                if (stream != null) {
                    try {
                        stream.close();
                    } catch (IOException e1) {
                    }
                }
            }
        }
    }

    public static File getFileInPlugin(IPath path) {
        try {
            return new File(Launching.class.getProtectionDomain().getCodeSource().getLocation().toURI().getPath() + path.toString());
        } catch (URISyntaxException e) {
           return null;
        }
    }

    /**
     * Compares two URL for equality, but do not connect to do DNS resolution
     *
     * @param url1
     *         a given URL
     * @param url2
     *         another given URL to compare to url1
     * @return <code>true</code> if the URLs are equal, <code>false</code> otherwise
     * @since 3.5
     */
    public static boolean sameURL(URL url1, URL url2) {
        if (url1 == url2) {
            return true;
        }
        if (url1 == null ^ url2 == null) {
            return false;
        }
        // check if URL are file: URL as we may have two URL pointing to the same doc location
        // but with different representation - (i.e. file:/C;/ and file:C:/)
        final boolean isFile1 = "file".equalsIgnoreCase(url1.getProtocol());//$NON-NLS-1$
        final boolean isFile2 = "file".equalsIgnoreCase(url2.getProtocol());//$NON-NLS-1$
        if (isFile1 && isFile2) {
            File file1 = new File(url1.getFile());
            File file2 = new File(url2.getFile());
            return file1.equals(file2);
        }
        // URL1 XOR URL2 is a file, return false. (They either both need to be files, or neither)
        if (isFile1 ^ isFile2) {
            return false;
        }
        return getExternalForm(url1).equals(getExternalForm(url2));
    }


    /**
     * Gets the external form of this URL. In particular, it trims any white space,
     * removes a trailing slash and creates a lower case string.
     *
     * @param url
     *         the URL to get the {@link String} value of
     * @return the lower-case {@link String} form of the given URL
     */
    private static String getExternalForm(URL url) {
        String externalForm = url.toExternalForm();
        if (externalForm == null) {
            return ""; //$NON-NLS-1$
        }
        externalForm = externalForm.trim();
        if (externalForm.endsWith("/")) { //$NON-NLS-1$
            // Remove the trailing slash
            externalForm = externalForm.substring(0, externalForm.length() - 1);
        }
        return externalForm.toLowerCase();

    }
}
